add JS-snippet support using from: .module("/path/to/js-module.js") syntax - #792
add JS-snippet support using from: .module("/path/to/js-module.js") syntax#792sliemeobn wants to merge 5 commits into
from: .module("/path/to/js-module.js") syntax#792Conversation
|
closes #508 |
There was a problem hiding this comment.
Nice feature — the design fits the existing patterns well and test coverage is strong. Details in the inline comments.
Two tiny things not worth their own threads: the registry error messages render a double slash (TestModule//Modules/x.mjs — path already starts with /), and a runtime test for a throwing module export would pin down the setException path too.
| ) | ||
| packageInputs.append( | ||
| make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in | ||
| let output = try linkBridgeJS(scope) |
There was a problem hiding this comment.
Splitting this into a second task means linkBridgeJS (decode all skeletons + full codegen) runs twice on every build — even for the majority of projects with zero modules, which also end up with an empty bridge-js-modules/ directory in their output package.
I get why the split is attractive: a separate task output means cleanEverything removes the directory. Memoizing the link result keeps that benefit without the double work — skeletons can't change within one build() invocation and MiniMake runs tasks sequentially, so the cache is safe:
var cachedLinkOutput: BridgeJSLinkOutput?
let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in
if let cachedLinkOutput { return cachedLinkOutput }
var link = BridgeJSLink(
sharedMemory: Self.isSharedMemoryEnabled(triple: triple)
)
for skeletonPath in skeletons {
let url = URL(fileURLWithPath: scope.resolve(path: skeletonPath).path)
try link.addSkeletonFile(data: Data(contentsOf: url))
}
let output = try link.link()
cachedLinkOutput = output
return output
}| for skeleton in skeletons { | ||
| for module in skeleton.imported?.modules ?? [] { | ||
| guard module.path.hasPrefix("/") else { | ||
| throw BridgeJSLinkError( |
There was a problem hiding this comment.
relativePath is spliced straight into outputDir.appending(path:) in PackagingPlanner. makeJavaScriptModuleFileLookup guarantees no traversal for locally generated skeletons, so this is safe today — but skeletons also arrive from dependency packages' committed BridgeJS.json, and a path like /../../evil.js in one of those would write outside the output directory (and emit an escaping import line). Cheap to close here since we're already validating:
guard module.path.hasPrefix("/"),
!module.path.split(separator: "/").contains("..")
else {
throw BridgeJSLinkError(
message: "JavaScript module path must start with '/' and must not contain '..': \(module.path)"
)
}| public init(from decoder: any Decoder) throws { | ||
| let value = try decoder.singleValueContainer().decode(String.self) | ||
| self = value == "global" ? .global : .module(value) | ||
| } |
There was a problem hiding this comment.
Encoding .module into the same single-value slot as .global is a nice backward-compatible trick, and the legacy-decode test covers it well. One downside: any unknown string (a future case, or a typo'd/hand-edited skeleton) silently decodes as .module("whatever"), and the failure surfaces much later as Missing embedded JavaScript module for Foo/whatever, which points nowhere useful. Since module paths are already required to start with /, the invalid case can fail loudly right at the boundary:
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
if value == "global" {
self = .global
} else if value.hasPrefix("/") {
self = .module(value)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unknown JSImportFrom value '\(value)'. Expected \"global\" or a '/'-rooted module path."
)
}
}| ``` | ||
|
|
||
| The leading `/` denotes the Swift target root, not the filesystem root. BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. | ||
|
|
There was a problem hiding this comment.
This PR itself had to add "Modules" to the exclude: list in Package.swift, and anyone following these docs will hit the same thing — SwiftPM's "found N file(s) which are unhandled" warning the moment they drop a .js file under their target. Worth calling out once here (and probably in the overview article too), e.g. right after this paragraph:
SwiftPM does not know what to do with `.js`/`.mjs` files inside a target, so exclude the directory holding them to avoid an "unhandled files" warning:
```swift
.target(
name: "MyApp",
exclude: ["JavaScript"],
plugins: [.plugin(name: "BridgeJS", package: "JavaScriptKit")]
)
```| /// JavaScript files must be declared as build-command inputs so SwiftPM reruns | ||
| /// BridgeJS when a referenced module changes. | ||
| func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] { | ||
| guard |
There was a problem hiding this comment.
Every .js/.mjs anywhere under the target becomes a declared build-command input and is appended to the tool's argv, referenced or not. For a target that vendors JS assets (or has a stray node_modules), that's a lot of spurious rebuild triggers and a long command line — and a directory named foo.js currently passes the filter, only to fail later at String(contentsOf:) with a confusing error. The sibling recursivelyCollectSwiftFiles in BridgeJSTool.swift checks isRegularFile; doing the same here plus a small skip-list would cover it:
private let skippedDirectoryNames: Set<String> = ["node_modules"]
func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] {
guard
let enumerator = FileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
else { return [] }
var files: [URL] = []
for case let fileURL as URL in enumerator {
if skippedDirectoryNames.contains(fileURL.lastPathComponent) {
enumerator.skipDescendants()
continue
}
guard isJavaScriptModuleFile(fileURL),
(try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
else { continue }
files.append(fileURL)
}
return files.sorted { $0.path < $1.path }
}(.build and other dot-directories are already covered by .skipsHiddenFiles.)
|
|
||
| You can bring JavaScript into Swift in two ways: | ||
| You can bring JavaScript into Swift in three ways: | ||
|
|
There was a problem hiding this comment.
Tiny one: the intro at line 11 of this article still says imports work in "two ways", so the article now disagrees with itself:
You can import JavaScript APIs into Swift in three ways:
allow to ship JS code as the source for imports (in addition to ".global" and explicitly provided imports)
I noticed that currently there is no diagnostic when
from:is used in places that do not support it, I did not add these checks (eg: class members, setters) to this PR more focussed. (#793)